C#网络编程

简易聊天室

作者:陈广 日期:2018-11-8


之前我们做了一个简单的点对点聊天程序,接下来弄一个简单的聊天室程序。聊天室相信大家都玩过,就是很多人在一个房间里聊天,每个人说的话,房间里的人都能看到,它和 QQ 的群聊类似。聊天室的思路就和点对点聊天程序完全不一样了。在点对点聊天中,是服务器和客户端两者间的私聊。而聊天室,服务器的角色发生了变化,它的作用是转发或群发每个客户端的聊天信息。我们可以把服务器理解为消息中转站,所有客户端的聊天信息都需要经过服务器才能让别的客户端看到。

需求

接下来我们构思简易聊天室所需要实现的功能:

  • 聊天室使用 windows 应用程序实现,可以显示聊天室内的成员列表和聊天信息。
  • 客户端登录服务器需要使用用户名,聊天室的用户名不能重复,一台电脑上可以启动多个客户端登录聊天室。
  • 新用户加入聊天室时,聊天室的所有成员均能收到新成员的加入信息。
  • 新用户加入聊天室时,会收到所有在线成员信息并显示在列表中。
  • 聊天室任一成员发送信息,聊天室的所有成员均能收到。
  • 聊天室有成员退出时,所有成员均能收到消息,并在成员列表中删除此退出成员。

功能并不复杂,但需要实现,就必须自己写通信协议了,这是这篇文章要讲的重点。

协议设计

之前的点对点聊天程序只是两台电脑间的通信,双方互发字符串就行了,非常简单。但是这个聊天室就不同了。试想,服务器发了一条信息给客户端,客户端如何知道这条信息是聊天信息,亦或是通知有新用户上线,又或者是有用户退出呢?这些信息是否应该做些标记以表明它的用途呢?这里所说的标记就是协议,如同碟战片里两个人对暗号,能说出双方认同的暗号,两个人就联系上了。网络世界协议无处不在,我们熟悉的有 TCP、UDP、HTTP 等等,这些协议都非常复杂。本文要设计的是一个非常简单的协议,让你对协议有一个初步认知。

鉴于此聊天室功能简单,我们只需将 Socket 数据包的第一个字节作为以上所说的标记,用于表明此数据包的用途。

下表为客户端发给服务器的数据包的第一个字节的作用,以后我们称第一个字节为标志位,它存放的是一个数字:

数字 作用
1 表示客户端想加入聊天室
2 表示客户端要发送聊天信息

下表为服务器发送给客户端的数据包的第一个字节的作用:

数字 作用
1 服务器向客户端发送聊天室所有成员列表
2 表示有新成员加入聊天室,将新成员信息传给客户端
3 服务器发送的是聊天信息
4 表示有成员退出聊天室,将退出成员信息传给客户端
255 通知客户端存在同名用户,不允许加入聊天室

接下来是协议的具体设计。

加入聊天室

  1. 当一个客户端连接了服务器,然后向服务器发送第一条信息,标志位设为 1,表明自己要加入聊天室。同时客户端还需要将自己的用户名传送给服务器,最终将用户名定为不超过30个字节。我们准备使用 Unicode 编码,也就是说可以使用中文名或英文名,用户名中的字母或中文个数不要超过14个。最终数据包格式如下图所示:

图 1: 客户端 -> 服务器 加入聊天室
  1. 服务器在接收到客户端的加入请求后,首先查看是否存在同名用户,如果存在,则向客户端发送一个字节:255。

  2. 首先将收到的新用户名称传给聊天室内的所有用户。标志位为 2,后跟新用户名,最终数据包格式如下图所示:

图 2: 服务器 -> 客户端 向所有在线用户群发新加入用户名
  1. 接下来服务器还要将所有在线用户名称列表发送给新加入的客户端,标志位为 1,所有在线用户名跟在后面,用户名之间用'\0'分隔。最终数据包格式如下图所示:

图 3: 服务器 -> 客户端 向新用户发送在线用户名单列表

至此,加入聊天室的动作全部完成,还是比较麻烦的。

发送聊天信息

  1. 新用户加入聊天室后,可以开始聊天了。聊天的标志位为 2,之后为聊天内容,为不定长,根据字符串实际长度决定。我们规定不超过 1023 个字节就行了。数据包格式如下图所示:

图 4: 客户端 -> 服务器 向服务器发送聊天信息
  1. 服务器在收到客户端发来的聊天信息后,在信息中间加上用户名,把它转发给所有在线用户。标志位为 3。数据包格式如下图所示:

图 5: 服务器 -> 客户端 服务器向在线用户群发聊天信息

退出聊天室

客户端在退出程序后,服务器会侦测到客户端断开连接并引发异常,客户端无需发送专门的退出消息。服务器在异常中向所有在线用户发送此用户退出消息。标志位为 4。数据包格式如下图所示:

图 6: 服务器 -> 客户端 服务器向客户端群发某用户退出消息

汇总

最后将以上协议按客户端和服务器端分类列出,方便之后编写程序。

首先是客户端向服务器发送的消息的格式:

用途 协议格式
加入聊天室 1 + 用户名
发送聊天信息 2 + 聊天内容

接下来是服务器向客户发送的消息格式:

用途 协议格式 发送方式
发送聊天室所有成员列表 1 + 用户名1 + 0 + 用户名2 + 0 + 用户名3 + 0 + ···
发送新成员信息 2 + 新用户名 群发
发送的是聊天信息 3 + 聊天内容 群发
发送用户退出信息 4 + 用户名 群发

编写程序

设计好协议,接下来编写程序。

服务器端

服务器端的主要问题是需要一个存储用户信息的集合类和处理同步问题。 由于大部分操作为遍历操作,其实应该使用红黑树实现的SortedDictionary最为合适,它可以按名字排序遍历所有用户,但此类并没有对就的线程安全类,所以最终只好选择线程安全类ConcurrentDictionary。此类支持多线程读写,所以直接解决了上面提到的两个问题。直接上代码:

using System;
using System.Threading.Tasks;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Threading;

namespace ChatRoomServer
{
    class Program
    {
        static ConcurrentDictionary<string, Socket> users = new ConcurrentDictionary<string, Socket>();
        static readonly object _syncRoot = new object(); //同步锁

        static void Main(string[] args)
        {
            IPAddress hostIP = IPAddress.Parse("172.16.0.11");
            Console.WriteLine("IP地址:" + hostIP.ToString());
            IPEndPoint point = new IPEndPoint(hostIP, 5000);
            
            Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            try
            {
                serverSocket.Bind(point);
                serverSocket.Listen(70); //最多容纳70个人
                Console.WriteLine("启动聊天室...");
                while (true)
                {
                    Socket subSocket = serverSocket.Accept();
                    Console.WriteLine("收到来自{0}的连接请求", subSocket.RemoteEndPoint.ToString());
                    Thread tRecv = new Thread(() => RecvMsg(subSocket)); //专门针对新连接开一个线程进行消息接收
                    tRecv.IsBackground = true;
                    tRecv.Start();
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
            }
        }

        //用于和单个客户端连接的线程
        static void RecvMsg(Socket s)
        {
            byte[] recvBuff = new byte[1024];
            string userName = ""; //用户名
            byte[] userNameByte = new byte[0]; //表示用户名的字节数组
            try
            {
                while (true)
                {
                    int count = s.Receive(recvBuff, recvBuff.Length, SocketFlags.None);
                    if (count == 0) //收到长度为 0 的消息说明 Socket 出问题了
                    {
                        CloseSocket(s);
                        return;
                    }
                    if (recvBuff[0] == 1)
                    {//用户请求加入聊天室
                     //解析出用户名
                        userName = Encoding.Unicode.GetString(recvBuff, 1, count - 1);
                        userNameByte = Encoding.Unicode.GetBytes(userName + ":");

                        //首先检查是否存在同名用户,如果存在则向请求者发送存在同名用户信息:255
                        if (userName == "" || users.ContainsKey(userName))
                        {
                            //存在同名,不再继续下面步骤                        
                            byte[] buff = new byte[1];
                            buff[0] = 255;
                            s.Send(buff, 1, SocketFlags.None);
                            CloseSocket(s);
                            return;
                        }

                        //向所有在线用户发送新用户上线信息:2+用户名
                        byte[] sendByte = new byte[count];
                        Array.Copy(recvBuff, sendByte, count);
                        sendByte[0] = 2;
                        SendAllUsers(sendByte);

                        //将新用户信息加入用户清单
                        users.TryAdd(userName, s);

                        //向新用户发送在线用户名单列表
                        int index = 1; //指示当前压入字节的进度
                        byte[] sendBuff = new byte[2048];
                        sendBuff[0] = 1; //标志位设为 1
                                         //压入所有在线用户名,用户名之间用'\0'分隔
                        foreach (KeyValuePair<string, Socket> pair in users)
                        {
                            byte[] temp = Encoding.Unicode.GetBytes(pair.Key);//将用户名转化为字节数组
                            temp.CopyTo(sendBuff, index); //压入用户名
                            index += temp.Length;
                            //压入指示一个用户名结束的标志'\0'
                            sendBuff[index] = 0;
                            sendBuff[index + 1] = 0;
                            index += 2;
                        }

                        s.Send(sendBuff, index, SocketFlags.None); //向客户端发送信息
                    }
                    else if (recvBuff[0] == 2)//用户发来聊天信息,需要群发给所有在线用户。格式:3 + 用户名:+ 聊天信息
                    {
                        int index = 1; //指示当前压入字节的进度
                        byte[] sendByte = new byte[count + userNameByte.Length];
                        sendByte[0] = 3; //将标志位设为 3                                                    
                        Array.Copy(userNameByte, 0, sendByte, index, userNameByte.Length); //压入用户名
                        index += userNameByte.Length;
                        Array.Copy(recvBuff, 1, sendByte, index, count - 1); //拷贝用户发来的聊天信息
                        SendAllUsers(sendByte);
                    }
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
                if (userName == "")
                {
                    CloseSocket(s);
                }
                else
                {
                    RemoveUser(userName, s);
                }
            }
        }

        //用于向所有用户群发消息
        static void SendAllUsers(byte[] sendByte)
        {
            foreach (KeyValuePair<string, Socket> pair in users)
            {   //可以快速完成的线程使用线程池
                Task.Run(() => SendMessage(pair.Key, pair.Value, sendByte));
            }
        }
        //用于向单个用户以异步的方式发送消息,群发时使用
        static void SendMessage(string name, Socket s, byte[] sendByte)
        {
            try
            {
                if (s != null && s.Connected)
                {
                    byte[] buff = (byte[])sendByte.Clone();
                    s.Send(buff, buff.Length, SocketFlags.None);
                }
                else
                {
                    RemoveUser(name, s);
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
                RemoveUser(name, s);
            }
        }

        //用于删除用户
        static void RemoveUser(string name, Socket s)
        {
            users.TryRemove(name, out s);
            CloseSocket(s);

            //向在线用户群发退出信息:4 + 用户名
            byte[] nameByte = Encoding.Unicode.GetBytes(name);
            byte[] sendBuff = new byte[1 + nameByte.Length];
            sendBuff[0] = 4;
            nameByte.CopyTo(sendBuff, 1);
            SendAllUsers(sendBuff);
            Console.WriteLine(name + "--退出聊天室");
        }

        //用于关闭 Socket
        private static void CloseSocket(Socket s)
        {
            if (s != null && s.Connected)
            {
                try
                {
                    s.Shutdown(SocketShutdown.Both);
                }
                catch (Exception e) { }
                Thread.Sleep(10);
                s.Close();
            }
        }
    }
}

客户端

客户端主要还是处理多线程 UI 访问相对麻烦些。请先按下图进行界面设计

图 7: 客户端界面设计

代码如下:

using System;
using System.Text;
using System.Threading.Tasks;
using System.Threading;
using System.Windows.Forms;
using System.Net;
using System.Net.Sockets;

namespace ChatRoomClient
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }
        Socket clientSocket;
        
        private void btnLogin_Click(object sender, EventArgs e)
        {
            if(txtUserName.Text=="")
            {
                MessageBox.Show("用户名不能为空!");
                return;
            }
            IPAddress ip = IPAddress.Parse(txtIP.Text);
            int port = int.Parse(txtPort.Text);
            clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            try
            {
                clientSocket.Connect(ip, port);          
                if (clientSocket.Connected)
                {
                    txtMsg.Text = "连接服务器 " + txtIP.Text + " 成功";
                    //向服务器发送加入聊天室请求
                    byte[] nameByte = Encoding.Unicode.GetBytes(txtUserName.Text);
                    byte[] buff = new byte[1 + nameByte.Length];
                    buff[0] = 1;
                    nameByte.CopyTo(buff, 1);
                    clientSocket.Send(buff, buff.Length, SocketFlags.None);
                }
                Task.Factory.StartNew(() => ReceiveMessage(), TaskCreationOptions.LongRunning);
            }
            catch (Exception ex)
            {
                txtMsg.Text += "\r\n" + ex.Message;
                clientSocket.Close();
            }
        }

        private void ReceiveMessage()
        {
            byte[] buff = new byte[2048];
            try
            {
                while (true)
                {
                    int count = clientSocket.Receive(buff, buff.Length, SocketFlags.None);
                    switch (buff[0])
                    {
                        case 1: //服务器向客户端发送聊天室所有成员列表,此时登录才算完全成功
                            string usersStr = Encoding.Unicode.GetString(buff, 1, count - 3);
                            string[] users = usersStr.Split('\0');
                            this.Invoke(new Action(() =>
                            {
                                lstUser.Items.AddRange(users);
                                btnLogin.Enabled = false;
                                btnSend.Enabled = true;
                            }));
                            break;
                        case 2: //表示有新成员加入聊天室,将新成员信息传给客户端
                            string newUser = Encoding.Unicode.GetString(buff, 1, count - 1);
                            this.Invoke(new Action(() =>
                            {
                                lstUser.Items.Add(newUser);
                                txtMsg.Text += "\r\n" + newUser + " 加入了聊天室";
                            }));
                            break;
                        case 3: //服务器发送的是聊天信息
                            string message = Encoding.Unicode.GetString(buff, 1, count - 1);
                            this.Invoke(new Action(() => txtMsg.Text += "\r\n" + message));
                            break;
                        case 4: //表示有成员退出聊天室,将退出成员信息传给客户端
                            string exitUser = Encoding.Unicode.GetString(buff, 1, count - 1);
                            this.Invoke(new Action(() =>
                            {
                                lstUser.Items.Remove(exitUser);
                                txtMsg.Text += "\r\n" + exitUser + " 退出了聊天室";
                            }));
                            break;
                        case 255: //存在同名用户,登录失败
                            this.Invoke(new Action(() =>
                            {
                                txtMsg.Text += "\r\n已存在名为“" + txtUserName.Text + "”的用户,请换一个名称登录";
                            }));
                            CloseSocket();
                            break;
                    }
                }
            }
            catch (Exception e)
            {
                this.Invoke(new Action(() =>
                {
                    txtMsg.Text += "\r\n" + e.Message;
                    btnLogin.Enabled = true;
                    btnSend.Enabled = false;
                }));
            }
            finally
            {
                CloseSocket();
            }
        }
        private void btnSend_Click(object sender, EventArgs e)
        {
            if (txtSend.Text != "")
            {
                byte[] msgByte = Encoding.Unicode.GetBytes(txtSend.Text);
                byte[] sendBuff = new byte[msgByte.Length + 1];
                sendBuff[0] = 2;
                msgByte.CopyTo(sendBuff, 1);
                clientSocket.Send(sendBuff, sendBuff.Length, SocketFlags.None);
                txtSend.Clear(); //清除发送文本框
            }
        }

        private void CloseSocket()
        {
            if (clientSocket != null && clientSocket.Connected)
            {
                try
                {
                    clientSocket.Shutdown(SocketShutdown.Both);
                }
                catch (Exception e) { }
                Thread.Sleep(10);
                clientSocket.Close();
            }
        }

        private void txtMsg_TextChanged(object sender, EventArgs e)
        {
            txtMsg.SelectionStart = txtMsg.TextLength;
            txtMsg.ScrollToCaret();
        }
    }
}
;

© 2018 - IOT小分队文章发布系统 v0.3